iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Software Development

從 Laravel 到 Spring Boot:30 天打造縮網址服務系列 第 9

Day9 - 層架構與 DTO/Mapper(縮網址核心 API 雛形)

  • 分享至 

  • xImage
  •  

先把三層串起來看全貌

Day05 建了 Controller 骨架(先寫死回應),Day07 建了 LinkServiceLinkRepository(真正查資料庫),但這兩塊目前還沒接在一起——Controller 呼叫的還是假資料。這篇要做的事很單純:把 Day05 的 Controller 改成呼叫 Day07 已經寫好的 LinkService

Controller   (接請求、組回應,不寫商業邏輯)
   ↓
Service      (商業邏輯、呼叫 Repository,Day07 已經寫好)
   ↓
Repository   (資料庫存取,Day07 已經寫好)

Laravel 沒有強制要求這三層——很多 Laravel 專案就是 Controller 直接操作 Model,兩層就結束,Service 層是不是要獨立出來完全看團隊習慣。Spring 生態系的慣例則比較固定地走三層,這不是 Spring 框架本身強制的規則,是社群長期累積下來的慣例,但幾乎所有 Spring 專案都會照著做。

Controller 接上 Service

@RestController
@RequestMapping("/api/links")
class LinkController {

    private final LinkService linkService;

    LinkController(LinkService linkService) {
        this.linkService = linkService;
    }

    @PostMapping
    ResponseEntity<LinkResponse> store(@Valid @RequestBody CreateLinkRequest request) {
        LinkResponse response = linkService.create(request);
        return ResponseEntity.ok(response);
    }

    @GetMapping("/{code}")
    ResponseEntity<LinkResponse> show(@PathVariable String code) {
        return linkService.findByCode(code)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
}

跟 Day05 的版本比,Controller 現在完全不碰資料庫、不寫任何商業邏輯——它唯一的工作是「接請求、呼叫 Service、把結果包成 HTTP 回應」。findByCode 回傳的 Optional(Day02、Day07 都提過的型別)在這裡發揮作用:有值就回 200,沒有值就回 404,一行寫完,不用寫 if (result == null)

為什麼不能讓 Controller 直接回傳 Entity

Spring 其實技術上做得到——@RestController 會自動把回傳的物件序列化成 JSON,就算回傳的是 JPA Entity 也能動:

// 技術上能跑,但不建議
@GetMapping("/{code}")
Link show(@PathVariable String code) {
    return linkRepository.findByCode(code).orElseThrow();
}

問題不在「跑不跑得動」,是這樣做會有幾個實際的坑:

  1. 內部欄位意外外洩Link entity 有 ownerId 這種內部欄位,直接序列化整個 Entity,等於把「這個短網址是誰建的」也回給了呼叫端的前端,就算現在用不到,也不該暴露
  2. API 合約跟資料庫 schema 綁死:Entity 的欄位就是資料表的欄位,一旦資料表改欄位名稱,API 回應格式就跟著變,呼叫這支 API 的人完全不知情
  3. JPA 延遲載入序列化炸掉:如果 Entity 之後加了關聯欄位(例如 Link 關聯到 User),沒設定好可能會在序列化時觸發額外的資料庫查詢,甚至丟例外

這正是這篇要解決的問題——用一個獨立的 DTO(Data Transfer Object)當作 API 對外的回應格式,跟內部的 Entity 完全脫鉤。LinkResponse 這個 record 其實從 Day05 就已經是 DTO 了:

record LinkResponse(String code, String shortUrl) {}

只暴露呼叫端真正需要的兩個欄位,ownerIdclickCountcreatedAt 這些內部細節完全不會外流。

Mapper:Entity 轉 DTO,對照 Laravel API Resource

Day07 的 LinkService.create() 裡其實已經在做「Entity 轉 DTO」這件事,只是寫得很直白:

Link saved = linkRepository.save(link);
return new LinkResponse(saved.getCode(), "https://short.ly/" + saved.getCode());

這種手動組裝的轉換邏輯,就是 Mapper 的雛形。現在專案還小,直接寫在 Service 裡沒問題;等轉換邏輯變複雜(例如同一個 Entity 要轉成好幾種不同用途的 DTO),會抽成獨立的 Mapper 類別或方法。

Laravel 對應的概念是 API Resource

class LinkResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'code' => $this->code,
            'short_url' => 'https://short.ly/' . $this->code,
        ];
    }
}

// Controller
return new LinkResource($link);

兩邊做的事情本質相同——都是「決定 Entity/Model 裡哪些欄位要暴露、用什麼形狀暴露」——差別在 Laravel 的 API Resource 是框架提供的專屬機制、有固定的基底類別可以繼承;Spring 沒有官方的「Mapper」機制,手動寫轉換方法,或使用像 MapStruct 這類第三方函式庫在編譯期自動生成轉換程式碼(這系列目前先手動寫,函式庫留待之後視情況評估)。

目前完整可動的雛形

到這篇為止,POST /api/links 已經是一條完整走通的路徑:Controller 接請求 → Validation(Day06)通過 → Service 呼叫 Repository(Day07)存進資料庫(Day08 的 migration 建好的表)→ 組成 DTO 回傳。短碼目前還是 Day07 寫死的假值 "abc123",真正的 base62 產生邏輯留到 Day14;例外處理目前也還是 Spring 預設的原始格式,統一整理留到 Day10。這篇算是縮網址服務第一個「端到端」能跑的版本,接下來幾天會逐一把還沒處理好的地方補齊。


上一篇
Day8 - 資料庫遷移 Flyway/Liquibase vs Laravel Migration
系列文
從 Laravel 到 Spring Boot:30 天打造縮網址服務9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言